diff --git a/landing/keyservers.react.js b/landing/keyservers.react.js index 157aa8c43..cf4409094 100644 --- a/landing/keyservers.react.js +++ b/landing/keyservers.react.js @@ -1,147 +1,147 @@ // @flow import { create } from '@lottiefiles/lottie-interactivity'; import * as React from 'react'; import { useIsomorphicLayoutEffect } from 'lib/hooks/isomorphic-layout-effect.react.js'; import { assetsCacheURLPrefix } from './asset-meta-data.js'; import css from './keyservers.css'; import ReadDocsButton from './read-docs-btn.react.js'; import StarBackground from './star-background.react.js'; function Keyservers(): React.Node { React.useEffect(() => { import('@lottiefiles/lottie-player'); }, []); const onEyeIllustrationLoad = React.useCallback(() => { create({ mode: 'scroll', player: '#eye-illustration', actions: [ { visibility: [0, 1], type: 'seek', frames: [0, 720], }, ], }); }, []); const onCloudIllustrationLoad = React.useCallback(() => { create({ mode: 'scroll', player: '#cloud-illustration', actions: [ { visibility: [0, 0.2], type: 'stop', frames: [0], }, { visibility: [0.2, 1], type: 'seek', frames: [0, 300], }, ], }); }, []); const [eyeNode, setEyeNode] = React.useState(null); useIsomorphicLayoutEffect(() => { if (!eyeNode) { return undefined; } eyeNode.addEventListener('load', onEyeIllustrationLoad); return () => eyeNode.removeEventListener('load', onEyeIllustrationLoad); }, [eyeNode, onEyeIllustrationLoad]); const [cloudNode, setCloudNode] = React.useState(null); useIsomorphicLayoutEffect(() => { if (!cloudNode) { return undefined; } cloudNode.addEventListener('load', onCloudIllustrationLoad); return () => cloudNode.removeEventListener('load', onCloudIllustrationLoad); }, [cloudNode, onCloudIllustrationLoad]); return (

Reclaim your digital identity.

The Internet is broken today. Private user data is owned by mega-corporations and farmed for their benefit.

E2E encryption has the potential to change this equation. But - it's constrained by a crucial limitation. + it’s constrained by a crucial limitation.

Apps need servers.

Sophisticated applications rely on servers to do things that your - devices simply can't. + devices simply can’t.

- That's why E2E encryption only works for simple chat apps - today. There's no way to build a robust server layer that has + That’s why E2E encryption only works for simple chat apps + today. There’s no way to build a robust server layer that has access to your data without leaking that data to corporations.

Comm {' '} is the keyserver{' '} company.

In the future, people have their own servers.

Your keyserver is the home of your digital identity. It owns your - private keys and your personal data. It's your password + private keys and your personal data. It’s your password manager, your crypto bank, your digital surrogate, and your second brain.

); } export default Keyservers; diff --git a/landing/privacy.react.js b/landing/privacy.react.js index 75f9c27f9..3fdb4ef6a 100644 --- a/landing/privacy.react.js +++ b/landing/privacy.react.js @@ -1,428 +1,428 @@ // @flow import * as React from 'react'; import css from './legal.css'; function Privacy(): React.Node { return (

Privacy Policy

Effective date: February 2, 2023

Introduction

We built Comm as a privacy-focused alternative to the cloud-based community chat apps that exist today. In order to protect the privacy of user content, communities on Comm are hosted on private keyservers, and messages transmitted through the platform are encrypted, preventing us from being able to access their contents. While Comm does collect a minimal amount of information as necessary to power and maintain the Services, Comm will never be able to access or view the substance of your Content. This Privacy Policy describes the limited ways in which Comm uses information in connection with its Services.

‌By using or accessing our Services, you acknowledge that you accept the practices and policies outlined below.

Table of Contents

Information We Collect

We collect the following categories of information, some of which might constitute personal information:

  1. Account Information. In order to use Comm, you will be required to create an account, and in doing so, may be required to provide a username and password to us. We only store salted and hashed versions of your password – we never store the plaintext version. No other information is required to create an account and we do not want you to provide any other information.
  2. Blockchain Account Information. You may instead choose to log into Comm using a third-party blockchain provider (such as, Sign-In with Ethereum or similar functionality), in which case you will be required to provide your blockchain address to us. The only information we collect as part of this process is information that is already publicly available.
  3. Backup Service. You have the option to back up your Content on Comm’s hosted servers, allowing you to recover any backed-up Content. If you do not opt out of the backup service, your Content will be stored in our automated backup service.{' '} Your Content will always remain end-to-end encrypted, even when stored through our backup service. {' '} You may recover your backed-up Content through an automatic recovery process available through your account. If you request your Content, Comm will verify your identity cryptographically to verify that the Content you are requesting belongs to you; however, such request will not allow Comm to collect or view any information that is not already outlined in this Privacy Policy and your backed-up Content will only be associated with your account. The backup service only allows Comm to collect encrypted versions of your Content – at no point will Comm have the ability to access or view the substance of your Content nor will Comm ever have the ability to decrypt your Content collected through the backup service. You may opt-out of this backup service at any time by doing so within the app.
  4. Updates About the Services. If you choose to receive updates on Comm’s progress, you will be required to provide your email address to us. If you choose to send any emails to us in response to such updates or otherwise, we may collect the content of such emails. These email addresses, and any emails received from you, will not be associated with any accounts, and will not be used for any purpose other than to provide updates on Comm and to otherwise respond to you.
  5. Time Zone Detection. When responding to a web request, we use the requester’s IP address in order to determine which time zone to render timestamps in. We do not store these IP addresses or associate them with specific accounts.
  6. Security, Fraud and Abuse. In order to detect and prevent abuse of our Services and cyberattacks, we keep track of request metadata, which includes requests made by IP addresses as well as the frequency of those requests. This data is only stored on a short-term basis and is never associated with specific accounts, even if the requests themselves originate from or are associated with specific accounts. Additionally, we may view and block certain IP address ranges as necessary to comply with applicable United States export control laws and regulations.
  7. Optimizing the Services. In order to optimize the Services provided to you, we keep track of which version of Comm’s code you are running as well as the platform that you are using to run Comm’s code (e.g., iOS, Android, web server, keyserver). We only collect this information to provide the Services to you, and are not able to access or view the substance of your Content in connection with such collection.
  8. Crash Reports. If you choose to send us a crash report, we may collect data from such reports for purposes of debugging and system maintenance. These reports contain operational information such as telemetry data (e.g., information with respect to the recent connection between the client and server), metadata (e.g., time stamps from messages sent in a conversation or chat), and device data (e.g., your device’s operating system) but never contain the content of your messages.
  9. Push Notifications. If you choose to allow push notifications to your device, your device’s operating system’s provider will know that you are using the Services and may be able to see the Content you transmit using the Services. We only collect push tokens required to send you such notifications and these tokens do not permit us to access or view your Content.
  10. Contact List. If you allow us to do so, we can discover which contacts in your address book are Comm users by using technology designed to protect the privacy of you and your contacts. If you opt to discover other Comm users in your contact list, phone numbers from the contacts on your device, as well as your own phone number, will be hashed and transmitted to Comm in order to match you and your friends on the Services. Since this contact information is hashed, Comm cannot view or access the plaintext version.
  11. Cookies. We only use a single cookie per user in order to authenticate a user as being logged in to Comm. Most browsers allow you to decide whether to accept these cookies and whether to remove any cookies already on your device. If you disable these cookies, you will not be able to stay logged into Comm.
  12. Bots. We may communicate with you through the Services by using bots, and we will have access to any information you voluntarily provide in response to those bots. We only collect this information to provide the Services to you, and are not able to access or view the substance of any other Content in connection with such collection.
  13. Content. Other Comm users may have access to, and have the ability to store, all or some of the Content owned by you.{' '} However, as this Content is end-to-end encrypted, we have no ability to access or view the substance of your Content. {' '} For more information on how Comm works, please{' '} click here .
  14. Anonymized Data. We may create aggregated, de-identified or anonymized data from the information we collect from you, including by removing information that makes the data personally identifiable to you. We may use such aggregated, de-identified or anonymized data for our own lawful business purposes, including to analyze, build and improve the Services and promote our business, provided that we will not use such data in a manner that could identify you.

Public Blockchain Information

Information posted on a blockchain is publicly available and auditable. When you and other users use the Services, Comm’s applications will automatically make calls to third-party blockchain providers using users’ blockchain addresses (including yours) to access publicly available information about those blockchain addresses (e.g., ENS name, ENS avatar). Such publicly available information will then be displayed through the Services to you and/or other Comm users. This information is not shared or stored by Comm, and the only information accessible and displayed is information that is publicly available on the blockchain.

Additionally, if you choose to use Comm’s applications, third-party blockchain providers may also have access to your IP address. While your IP address is not shared by Comm, it is automatically accessible by such providers through your use of Comm’s applications, regardless of whether you intend to make such information available. This access is required in order to allow Comm users to make calls to third-party blockchain providers, and Comm has no control over what such third-party blockchain provider may do with your IP address.

How We May Share Your Information

We may share the information we collect with third parties for the reasons listed below.{' '} However, your Content will always remain end-to-end encrypted, even when stored in the backup service, and Comm has no ability to decrypt that information for any third party.{' '} Moreover, we will never sell, rent or monetize your information.

  1. To fulfill our legal obligations under applicable law, regulation, court order or other legal process.
  2. To protect the rights, property or safety of you, Comm Technologies or another party as required or permitted by law.
  3. To enforce any agreements with you.
  4. Pursuant to a merger, acquisition, bankruptcy or other transaction in which that third party assumes control of our business (in whole or in part).

While sharing your email address is entirely optional, if you do choose to share it, we may share that email address, and the contents of any email sent by us or you, with our third-party service providers who may assist us in providing updates to you. Such information would be shared with such third-party service providers for the sole purpose of providing you updates on Comm, and will never be associated with your account or sold, rented or otherwise monetized.

Additionally, Comm uses third-party hosting providers to host its central cloud servers, therefore we may share the information we collect with such hosting providers in order for them to host this information. This information will be shared with such hosting providers for the sole purpose of providing the Services to you, and your Content will always remain end-to-end encrypted when shared with such hosting providers, even when stored in the backup service.

Retention of Information

For any of your personal information that we collect, we retain such personal information for as long as you have an open account with us or, only with respect to the association between your account and public key, as otherwise necessary to provide our Services.

Your Rights in the Personal Data You Provide to Us

Your Rights

Under applicable data protection legislation, in certain circumstances, you have rights concerning your personal data. You have a right to:

  1. Access. You can request more information about the personal data we hold about you and request a copy of such personal data.
  2. ‌Rectification. You can correct any inaccurate or incomplete personal data we are holding about you.
  3. Erasure. You can request that we erase some or all of your personal data from our systems.
  4. Withdrawal of Consent. You have the right to withdraw your consent to our processing of your personal data at any time. Please note, however, that if you exercise this right, you may have to then provide express consent on a case-by-case basis for the use or disclosure of certain of your personal data, if such use or disclosure is necessary to enable you to utilize some or all of our Services.
  5. Portability. You can ask for a copy of your personal data in a machine-readable format and can also request that we transmit the data to another data controller where technically feasible.
  6. Objection. You can contact us to let us know that you object to the further use of your personal data.
  7. Restriction of Processing. You can ask us to restrict further processing of your personal data.
  8. Right to File Complaint. You have the right to lodge a complaint with national data protection authorities regarding our processing of your personal data.

Exercising Your Rights

If you wish to exercise any of these rights, please contact us using the details below.

Deleting Your Data

If you would like to delete your account, you can do this either by deleting your account within the app, or by contacting us using the information below. Deleting your account removes all personal information that you provided to us.

Termination of your account may or may not result in destruction of Content associated with your account, and other Comm users may continue to have access to and control over your Content.

If you would like to delete any backed-up Content from our backup service, you may do so at any time by making a request within the app.

Changes to this Privacy Policy

We may update this Privacy Policy from time to time. If you use the Services after any changes to the Privacy Policy have been posted, that means you agree to all of the changes. Use of information we collect is subject to the Privacy Policy in effect at the time such information is collected.

Terms of Use

- Remember that your use of Comm Technologies' Services is at all + Remember that your use of Comm Technologies’ Services is at all times subject to our Terms of Use, which incorporates this Privacy Policy. Any terms we use in this Policy without defining them have the definitions given to them in the Terms of Use.

Contact Information

If you have any questions or comments about this Privacy Policy, the ways in which we collect and use your information or your choices and rights regarding such collection and use, please do not hesitate to contact us at:

); } export default Privacy; diff --git a/landing/qr.react.js b/landing/qr.react.js index 3e7fdbcf0..8b0c1585d 100644 --- a/landing/qr.react.js +++ b/landing/qr.react.js @@ -1,68 +1,68 @@ // @flow import { faTwitter, faGithub, faAppStoreIos, } from '@fortawesome/free-brands-svg-icons'; import { faBook, faHome, faBriefcase } from '@fortawesome/free-solid-svg-icons'; import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; import * as React from 'react'; import stores from 'lib/facts/stores.js'; import css from './qr.css'; function QR(): React.Node { const iconStyle = React.useMemo(() => ({ marginRight: '20px' }), []); return (

Comm

@CommDotApp

CommE2E/comm

App Store (pre-alpha)

Homepage

Technical docs

-

We're hiring!

+

We’re hiring!

); } export default QR; diff --git a/landing/terms.react.js b/landing/terms.react.js index 49d015e18..7c442c0dd 100644 --- a/landing/terms.react.js +++ b/landing/terms.react.js @@ -1,281 +1,281 @@ // @flow import * as React from 'react'; import css from './legal.css'; function Terms(): React.Node { return (

Terms of Use

Effective date: June 29, 2021

These Terms of Use (the “Terms”) are a binding contract between you and{' '} COMM TECHNOLOGIES, INC. (“Comm Technologies,” “we” and “us”) and govern your use of our website, products, services and applications (the “Services”). Your use of the Services in any way means that you agree to all of these Terms, and these Terms will remain in effect while you use the Services. These Terms include the provisions in this document as well as those in the{' '} Privacy Policy.

Will these Terms ever change?

We may change the Terms at any time, but if we do, we will place a notice on our site located at{' '} https://comm.app. If you don’t agree with the new Terms, you are free to reject them; unfortunately, that means you will no longer be able to use the Services. If you use the Services in any way after a change to the Terms is effective, that means you agree to all of the changes.

What about my privacy?

For the current Comm Technologies Privacy Policy, please{' '} click here.

Minimum Age

You must be at least 13 years old to use our Services. The minimum age to use our services without parental approval may be higher in your home country.

What are the basics of using Comm?

In order to access Comm, you are required to sign up for an account and select a user name and password. You will not share your user name, account or password with anyone, and you must protect the security of your user name, account, password and any other access tools or credentials. You’re responsible for any activity associated with your user name and account.

Who will have access to my Content?

Comm Technologies does not have the ability to access, view or control anything you post, upload, share, store, or otherwise provide through the Services (your “Content”). {' '} However, other Comm users may have access to and control over your Content. For more information on how Comm works, please{' '} click here .

Are there restrictions in how I can use the Services?

You agree that you will not provide or contribute anything to the Services, or otherwise use or interact with the Services, in a manner that: (a) infringes or violates the right of Comm Technologies, or our users, or others, including intellectual property or other proprietary rights; or (b) violates any law or regulation, including without limitation, any applicable export control laws, privacy laws or any other purpose not reasonably intended by Comm Technologies.

What are my rights in the Services?

The Content displayed or performed or available on or through the Services may be protected by copyright and/or other intellectual property laws. You promise to abide by all copyright notices, trademark rules, information, and restrictions contained in any Content you access through the Services, and you won’t use, copy, reproduce, modify, translate, publish, broadcast, transmit, distribute, perform, upload, display, license, sell, commercialize or otherwise exploit for any purpose any Content not owned by you, (i) without the prior consent of the owner of that Content or (ii) in a way that violates someone else’s - (including Comm Technologies') rights. You further agree to comply + (including Comm Technologies’) rights. You further agree to comply with all applicable open source licenses.

Subject to these Terms, we grant each user of the Services a worldwide, non-exclusive, non-sublicensable and non-transferable license to use the Services.

Who is responsible for what is contributed to the Services?

Any information or Content posted or transmitted through the Services is the sole responsibility of the person from whom such Content originated, and you access all such information and Content at your own risk, and we aren’t liable for any errors or omissions in that information or Content or for any damages or loss you might suffer in connection with it.

What if I see something on the Services that infringes my copyright?

Comm Technologies does not host and has no access to or control over any Content provided through the Services and has no ability to remove any such Content. If you have any further questions, please contact{' '} support@comm.app.

Who is responsible for third-party services?

The Services may contain links or connections to third-party websites or services that are not owned or controlled by Comm Technologies. When you access third-party websites or use third-party services, you accept that there are risks in doing so, and that Comm Technologies is not responsible for such risks. We encourage you to be aware when you leave the Services and to read the terms and conditions and privacy policy of each third-party website or service that you visit or utilize.

Will Comm Technologies ever change the Services?

We’re always trying to improve our Services, so they may change over time. We may suspend or discontinue any part of the Services, or we may introduce new features or impose limits on certain features or restrict access to parts or all of the Services.

What if I want to stop using the Services?

You’re free to do that at any time, either by deleting your account within the app, or by contacting us at{' '} support@comm.app; please refer to our Privacy Policy, as well as the licenses above, to understand how we treat information you provide to us after you have stopped using our Services. Termination of your account may or may not result in destruction of Content associated with your account, and other Comm users may continue to have access to and control over your Content.

Comm Technologies is also free to terminate (or suspend access to) your use of the Services or your account for any reason at our discretion, including your breach of these Terms. Comm Technologies has the sole right to decide whether you are in violation of any of the restrictions set forth in these Terms.

Provisions that, by their nature, should survive termination of these Terms shall survive termination. By way of example, all of the following will survive termination: any limitations on our liability, any terms regarding ownership or intellectual property rights, and terms regarding disputes between us.

What else do I need to know?

Warranty Disclaimer.

Comm Technologies and its licensors, suppliers, partners, parent, subsidiaries or affiliated entities, and each of their respective officers, directors, members, employees, consultants, contract employees, representatives and agents, and each of their respective successors and assigns (Comm Technologies and all such parties together, the “Comm Technologies Parties”) make no representations or warranties concerning the Services, including without limitation regarding any content contained in or accessed through the Services, and the Comm Technologies Parties will not be responsible or liable for the accuracy, copyright compliance, legality, or decency of material contained in or accessed through the Services or any claims, actions, suits procedures, costs, expenses, damages or liabilities arising out of use of, or in any way related to your participation in, the Services. The Comm Technologies Parties make no representations or warranties regarding suggestions or recommendations of services or products offered or purchased through or in connection with the Services. THE SERVICES AND CONTENT ARE PROVIDED BY COMM TECHNOLOGIES (AND ITS LICENSORS AND SUPPLIERS) ON AN “AS-IS” BASIS, WITHOUT WARRANTIES OF ANY KIND, EITHER EXPRESS OR IMPLIED, INCLUDING, WITHOUT LIMITATION, IMPLIED WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE, NON- INFRINGEMENT, OR THAT USE OF THE SERVICES WILL BE UNINTERRUPTED OR ERROR-FREE. SOME STATES DO NOT ALLOW LIMITATIONS ON HOW LONG AN IMPLIED WARRANTY LASTS, SO THE ABOVE LIMITATIONS MAY NOT APPLY TO YOU. WE DO NOT CONTROL, AND ARE NOT RESPONSIBLE FOR, CONTROLLING HOW OR WHEN OUR USERS USE OUR SERVICES. WE ARE NOT RESPONSIBLE FOR THE ACTIONS OR INFORMATION (INCLUDING CONTENT) OF OUR USERS OR OTHER THIRD PARTIES.

Limitation of Liability.

TO THE FULLEST EXTENT ALLOWED BY APPLICABLE LAW, UNDER NO CIRCUMSTANCES AND UNDER NO LEGAL THEORY (INCLUDING, WITHOUT LIMITATION, TORT, CONTRACT, STRICT LIABILITY, OR OTHERWISE) SHALL ANY OF THE COMM TECHNOLOGIES PARTIES BE LIABLE TO YOU OR TO ANY OTHER PERSON FOR (A) ANY INDIRECT, SPECIAL, INCIDENTAL, PUNITIVE OR CONSEQUENTIAL DAMAGES OF ANY KIND, INCLUDING DAMAGES FOR LOST PROFITS, BUSINESS INTERRUPTION, LOSS OF DATA, LOSS OF GOODWILL, WORK STOPPAGE, ACCURACY OF RESULTS, OR COMPUTER FAILURE OR MALFUNCTION, (B) ANY SUBSTITUTE GOODS, SERVICES OR TECHNOLOGY, (C) ANY AMOUNT, IN THE AGGREGATE, IN EXCESS OF THE GREATER OF (I) ONE-HUNDRED ($100) DOLLARS OR (II) THE AMOUNTS PAID AND/OR PAYABLE BY YOU TO COMM TECHNOLOGIES THE IN CONNECTION WITH THE SERVICES IN THE TWELVE (12) MONTH PERIOD PRECEDING THIS APPLICABLE CLAIM OR (D) ANY MATTER BEYOND OUR REASONABLE CONTROL. SOME STATES DO NOT ALLOW THE EXCLUSION OR LIMITATION OF INCIDENTAL OR CONSEQUENTIAL OR CERTAIN OTHER DAMAGES, SO ABOVE LIMITATION AND EXCLUSIONS MAY NOT APPLY TO YOU.

Assignment.

You may not assign, delegate or transfer these Terms or your rights or obligations hereunder, or your Services account, in any way (by - operation of law or otherwise) without Comm Technologies' prior + operation of law or otherwise) without Comm Technologies’ prior written consent. We may transfer, assign, or delegate these Terms and our rights and obligations without consent.

Dispute Resolution.

These Terms are governed by and will be construed under the laws of the State of New York, without regard to the conflict of laws provisions thereof. Any legal action or proceeding relating to these Terms shall be brought exclusively in the state or federal courts located in New York County, New York, and each party consents to the jurisdiction thereof.

Miscellaneous

The failure of either you or us to exercise, in any way, any right herein shall not be deemed a waiver of any further rights hereunder. If any provision of these Terms are found to be unenforceable or invalid, that provision will be limited or eliminated, to the minimum extent necessary, so that these Terms shall otherwise remain in full force and effect and enforceable. You and Comm Technologies agree that these Terms are the complete and exclusive statement of the mutual understanding between you and Comm Technologies, and that these Terms supersede and cancel all previous written and oral agreements, communications and other understandings relating to the subject matter of these Terms. You hereby acknowledge and agree that you are not an employee, agent, partner, or joint venture of Comm Technologies, and you do not have any authority of any kind to bind Comm Technologies in any respect whatsoever.

); } export default Terms; diff --git a/native/account/registration/connect-ethereum.react.js b/native/account/registration/connect-ethereum.react.js index 4302f3b7f..4342e25dc 100644 --- a/native/account/registration/connect-ethereum.react.js +++ b/native/account/registration/connect-ethereum.react.js @@ -1,219 +1,219 @@ // @flow import * as React from 'react'; import { Text, View } from 'react-native'; import { exactSearchUser, exactSearchUserActionTypes, } from 'lib/actions/user-actions.js'; import type { SIWEResult } from 'lib/types/siwe-types.js'; import { useServerCall, useDispatchActionPromise, } from 'lib/utils/action-utils.js'; import RegistrationButtonContainer from './registration-button-container.react.js'; import RegistrationButton from './registration-button.react.js'; import RegistrationContainer from './registration-container.react.js'; import RegistrationContentContainer from './registration-content-container.react.js'; import type { RegistrationNavigationProp } from './registration-navigator.react.js'; import type { CoolOrNerdMode } from './registration-types.js'; import { type NavigationRoute, ExistingEthereumAccountRouteName, } from '../../navigation/route-names.js'; import { useStyles } from '../../themes/colors.js'; import EthereumLogoDark from '../../vectors/ethereum-logo-dark.react.js'; import SIWEPanel from '../siwe-panel.react.js'; export type ConnectEthereumParams = { +userSelections: { +coolOrNerdMode: CoolOrNerdMode, +keyserverUsername: string, }, }; type PanelState = 'closed' | 'opening' | 'open' | 'closing'; type Props = { +navigation: RegistrationNavigationProp<'ConnectEthereum'>, +route: NavigationRoute<'ConnectEthereum'>, }; function ConnectEthereum(props: Props): React.Node { const isNerdMode = props.route.params.userSelections.coolOrNerdMode === 'nerd'; const styles = useStyles(unboundStyles); let body; if (!isNerdMode) { body = ( Connecting your Ethereum wallet allows you to use your ENS name and - avatar in the app. You'll also be able to log in with your wallet + avatar in the app. You’ll also be able to log in with your wallet instead of a password. ); } else { body = ( <> Connecting your Ethereum wallet has three benefits: {'1. '} Your peers will be able to cryptographically verify that your Comm account is associated with your Ethereum wallet. {'2. '} - You'll be able to use your ENS name and avatar in the app. + You’ll be able to use your ENS name and avatar in the app. {'3. '} You can choose to skip setting a password, and to log in with your Ethereum wallet instead. ); } const [panelState, setPanelState] = React.useState('closed'); const openPanel = React.useCallback(() => { setPanelState('opening'); }, []); const onPanelClosed = React.useCallback(() => { setPanelState('closed'); }, []); const onPanelClosing = React.useCallback(() => { setPanelState('closing'); }, []); const siwePanelSetLoading = React.useCallback( (loading: boolean) => { if (panelState === 'closing' || panelState === 'closed') { return; } setPanelState(loading ? 'opening' : 'open'); }, [panelState], ); const onSkip = React.useCallback(() => { // show username selection screen }, []); const exactSearchUserCall = useServerCall(exactSearchUser); const dispatchActionPromise = useDispatchActionPromise(); const { navigate } = props.navigation; const onSuccessfulWalletSignature = React.useCallback( async (result: SIWEResult) => { const searchPromise = exactSearchUserCall(result.address); dispatchActionPromise(exactSearchUserActionTypes, searchPromise); const { userInfo } = await searchPromise; if (userInfo) { navigate<'ExistingEthereumAccount'>({ name: ExistingEthereumAccountRouteName, params: result, }); } else { // show avatar selection screen } }, [exactSearchUserCall, dispatchActionPromise, navigate], ); let siwePanel; if (panelState !== 'closed') { siwePanel = ( ); } return ( <> Do you want to connect an Ethereum wallet? {body} {siwePanel} ); } const unboundStyles = { scrollViewContentContainer: { flexGrow: 1, }, header: { fontSize: 24, color: 'panelForegroundLabel', paddingBottom: 16, }, body: { fontSize: 15, lineHeight: 20, color: 'panelForegroundSecondaryLabel', paddingBottom: 16, }, ethereumLogoContainer: { flexGrow: 1, alignItems: 'center', justifyContent: 'center', }, list: { paddingBottom: 16, }, listItem: { flexDirection: 'row', }, listItemNumber: { fontWeight: 'bold', fontSize: 15, lineHeight: 20, color: 'panelForegroundSecondaryLabel', }, listItemContent: { flexShrink: 1, fontSize: 15, lineHeight: 20, color: 'panelForegroundSecondaryLabel', }, }; export default ConnectEthereum; diff --git a/native/account/terms-and-privacy-modal.react.js b/native/account/terms-and-privacy-modal.react.js index f7e2adfc3..fa970c243 100644 --- a/native/account/terms-and-privacy-modal.react.js +++ b/native/account/terms-and-privacy-modal.react.js @@ -1,190 +1,190 @@ // @flow import { useIsFocused } from '@react-navigation/native'; import * as React from 'react'; import { ActivityIndicator, BackHandler, Linking, Platform, Text, View, } from 'react-native'; import { policyAcknowledgment, policyAcknowledgmentActionTypes, } from 'lib/actions/user-actions.js'; import { type PolicyType, policyTypes } from 'lib/facts/policies.js'; import { createLoadingStatusSelector } from 'lib/selectors/loading-selectors.js'; import { useDispatchActionPromise, useServerCall, } from 'lib/utils/action-utils.js'; import { acknowledgePolicy } from 'lib/utils/policy-acknowledge-utlis.js'; import Button from '../components/button.react.js'; import Modal from '../components/modal.react.js'; import type { RootNavigationProp } from '../navigation/root-navigator.react.js'; import type { NavigationRoute } from '../navigation/route-names.js'; import { useSelector } from '../redux/redux-utils.js'; import { useStyles } from '../themes/colors.js'; export type TermsAndPrivacyModalParams = { +policyType: PolicyType, }; type Props = { +navigation: RootNavigationProp<'TermsAndPrivacyModal'>, +route: NavigationRoute<'TermsAndPrivacyModal'>, }; const loadingStatusSelector = createLoadingStatusSelector( policyAcknowledgmentActionTypes, ); function TermsAndPrivacyModal(props: Props): React.Node { const loadingStatus = useSelector(loadingStatusSelector); const [acknowledgmentError, setAcknowledgmentError] = React.useState(''); const sendAcknowledgmentRequest = useServerCall(policyAcknowledgment); const dispatchActionPromise = useDispatchActionPromise(); const policyType = props.route.params.policyType; const policyState = useSelector(store => store.userPolicies[policyType]); const isAcknowledged = policyState?.isAcknowledged; const isFocused = useIsFocused(); React.useEffect(() => { if (isAcknowledged && isFocused) { props.navigation.goBack(); } }, [isAcknowledged, props.navigation, isFocused]); const onAccept = React.useCallback(() => { acknowledgePolicy( policyTypes.tosAndPrivacyPolicy, dispatchActionPromise, sendAcknowledgmentRequest, setAcknowledgmentError, ); }, [dispatchActionPromise, sendAcknowledgmentRequest]); const styles = useStyles(unboundStyles); const buttonContent = React.useMemo(() => { if (loadingStatus === 'loading') { return ( ); } return I accept; }, [loadingStatus, styles.buttonText, styles.loading]); const onBackPress = props.navigation.isFocused; React.useEffect(() => { BackHandler.addEventListener('hardwareBackPress', onBackPress); return () => { BackHandler.removeEventListener('hardwareBackPress', onBackPress); }; }, [onBackPress]); const safeAreaEdges = ['top', 'bottom']; return ( Terms of Service and Privacy Policy We recently updated our  Terms of Service  &  Privacy Policy - . In order to continue using Comm, we're asking you to read + . In order to continue using Comm, we’re asking you to read through, acknowledge, and accept the updated policies. {acknowledgmentError} ); } const unboundStyles = { modal: { backgroundColor: 'modalForeground', paddingBottom: 10, paddingTop: 32, paddingHorizontal: 32, flex: 0, borderColor: 'modalForegroundBorder', }, header: { color: 'modalForegroundLabel', fontSize: 20, fontWeight: '600', textAlign: 'center', paddingBottom: 16, }, label: { color: 'modalForegroundSecondaryLabel', fontSize: 16, lineHeight: 20, textAlign: 'center', }, link: { color: 'purpleLink', fontWeight: 'bold', }, buttonsContainer: { flexDirection: 'column', marginTop: 24, height: 72, paddingHorizontal: 16, }, button: { borderRadius: 5, height: 48, justifyContent: 'center', alignItems: 'center', backgroundColor: 'purpleButton', }, buttonText: { color: 'white', fontSize: 16, fontWeight: '600', textAlign: 'center', }, error: { marginTop: 6, fontStyle: 'italic', color: 'redText', textAlign: 'center', }, loading: { paddingTop: Platform.OS === 'android' ? 0 : 6, }, }; const onTermsOfUsePressed = () => { Linking.openURL('https://comm.app/terms'); }; const onPrivacyPolicyPressed = () => { Linking.openURL('https://comm.app/privacy'); }; export default TermsAndPrivacyModal; diff --git a/native/chat/chat-input-bar.react.js b/native/chat/chat-input-bar.react.js index d1424369b..84aef3813 100644 --- a/native/chat/chat-input-bar.react.js +++ b/native/chat/chat-input-bar.react.js @@ -1,1309 +1,1309 @@ // @flow import Icon from '@expo/vector-icons/Ionicons.js'; import invariant from 'invariant'; import _throttle from 'lodash/throttle.js'; import * as React from 'react'; import { View, TextInput, TouchableOpacity, Platform, Text, ActivityIndicator, TouchableWithoutFeedback, NativeAppEventEmitter, } from 'react-native'; import Alert from 'react-native/Libraries/Alert/Alert.js'; import { TextInputKeyboardMangerIOS } from 'react-native-keyboard-input'; import Animated, { EasingNode, FadeInDown, FadeOutDown, } from 'react-native-reanimated'; import { useDispatch } from 'react-redux'; import { moveDraftActionType, updateDraftActionType, } from 'lib/actions/draft-actions.js'; import { joinThreadActionTypes, joinThread, newThreadActionTypes, } from 'lib/actions/thread-actions.js'; import { createLoadingStatusSelector } from 'lib/selectors/loading-selectors.js'; import { threadInfoSelector } from 'lib/selectors/thread-selectors.js'; import { userStoreSearchIndex } from 'lib/selectors/user-selectors.js'; import { colorIsDark } from 'lib/shared/color-utils.js'; import { useEditMessage } from 'lib/shared/edit-messages-utils.js'; import { getTypeaheadUserSuggestions, getTypeaheadRegexMatches, type Selection, getMentionsCandidates, } from 'lib/shared/mention-utils.js'; import { localIDPrefix, trimMessage, useMessagePreview, messageKey, type MessagePreviewResult, } from 'lib/shared/message-utils.js'; import SearchIndex from 'lib/shared/search-index.js'; import { threadHasPermission, viewerIsMember, threadFrozenDueToViewerBlock, threadActualMembers, checkIfDefaultMembersAreVoiced, draftKeyFromThreadID, } from 'lib/shared/thread-utils.js'; import type { CalendarQuery } from 'lib/types/entry-types.js'; import type { LoadingStatus } from 'lib/types/loading-types.js'; import type { PhotoPaste } from 'lib/types/media-types.js'; import { messageTypes } from 'lib/types/message-types-enum.js'; import type { SendEditMessageResponse, MessageInfo, } from 'lib/types/message-types.js'; import type { Dispatch } from 'lib/types/redux-types.js'; import { threadPermissions } from 'lib/types/thread-permission-types.js'; import { type ThreadInfo, type ClientThreadJoinRequest, type ThreadJoinPayload, type RelativeMemberInfo, } from 'lib/types/thread-types.js'; import { type UserInfos } from 'lib/types/user-types.js'; import { type DispatchActionPromise, useServerCall, useDispatchActionPromise, } from 'lib/utils/action-utils.js'; import { ChatContext } from './chat-context.js'; import type { ChatNavigationProp } from './chat.react.js'; import TypeaheadTooltip from './typeahead-tooltip.react.js'; import Button from '../components/button.react.js'; // eslint-disable-next-line import/extensions import ClearableTextInput from '../components/clearable-text-input.react'; import type { SyncedSelectionData } from '../components/selectable-text-input.js'; // eslint-disable-next-line import/extensions import SelectableTextInput from '../components/selectable-text-input.react'; import { SingleLine } from '../components/single-line.react.js'; import SWMansionIcon from '../components/swmansion-icon.react.js'; import { type InputState, InputStateContext, type EditInputBarMessageParameters, } from '../input/input-state.js'; import KeyboardInputHost from '../keyboard/keyboard-input-host.react.js'; import { type KeyboardState, KeyboardContext, } from '../keyboard/keyboard-state.js'; import { getKeyboardHeight } from '../keyboard/keyboard.js'; import { getDefaultTextMessageRules } from '../markdown/rules.react.js'; import { nonThreadCalendarQuery, activeThreadSelector, } from '../navigation/nav-selectors.js'; import { NavContext } from '../navigation/navigation-context.js'; import { type NavigationRoute, ChatCameraModalRouteName, ImagePasteModalRouteName, } from '../navigation/route-names.js'; import { useSelector } from '../redux/redux-utils.js'; import { type Colors, useStyles, useColors } from '../themes/colors.js'; import type { LayoutEvent } from '../types/react-native.js'; import { type AnimatedViewStyle, AnimatedView } from '../types/styles.js'; import { runTiming } from '../utils/animation-utils.js'; import { exitEditAlert } from '../utils/edit-messages-utils.js'; import { nativeTypeaheadRegex } from '../utils/typeahead-utils.js'; /* eslint-disable import/no-named-as-default-member */ const { Value, Clock, block, set, cond, neq, sub, interpolateNode, stopClock } = Animated; /* eslint-enable import/no-named-as-default-member */ const expandoButtonsAnimationConfig = { duration: 150, easing: EasingNode.inOut(EasingNode.ease), }; const sendButtonAnimationConfig = { duration: 150, easing: EasingNode.inOut(EasingNode.ease), }; type BaseProps = { +threadInfo: ThreadInfo, }; type Props = { ...BaseProps, +viewerID: ?string, +draft: string, +joinThreadLoadingStatus: LoadingStatus, +threadCreationInProgress: boolean, +calendarQuery: () => CalendarQuery, +nextLocalID: number, +userInfos: UserInfos, +colors: Colors, +styles: typeof unboundStyles, +onInputBarLayout?: (event: LayoutEvent) => mixed, +openCamera: () => mixed, +isActive: boolean, +keyboardState: ?KeyboardState, +dispatch: Dispatch, +dispatchActionPromise: DispatchActionPromise, +joinThread: (request: ClientThreadJoinRequest) => Promise, +inputState: ?InputState, +userSearchIndex: SearchIndex, +mentionsCandidates: $ReadOnlyArray, +parentThreadInfo: ?ThreadInfo, +editedMessagePreview: ?MessagePreviewResult, +editedMessageInfo: ?MessageInfo, +editMessage: ( messageID: string, text: string, ) => Promise, +navigation: ?ChatNavigationProp<'MessageList'>, }; type State = { +text: string, +textEdited: boolean, +buttonsExpanded: boolean, +selectionState: SyncedSelectionData, +isExitingEditMode: boolean, }; class ChatInputBar extends React.PureComponent { textInput: ?React.ElementRef; clearableTextInput: ?ClearableTextInput; selectableTextInput: ?React.ElementRef; expandoButtonsOpen: Value; targetExpandoButtonsOpen: Value; expandoButtonsStyle: AnimatedViewStyle; cameraRollIconStyle: AnimatedViewStyle; cameraIconStyle: AnimatedViewStyle; expandIconStyle: AnimatedViewStyle; sendButtonContainerOpen: Value; targetSendButtonContainerOpen: Value; sendButtonContainerStyle: AnimatedViewStyle; clearBeforeRemoveListener: () => void; constructor(props: Props) { super(props); this.state = { text: props.draft, textEdited: false, buttonsExpanded: true, selectionState: { text: props.draft, selection: { start: 0, end: 0 } }, isExitingEditMode: false, }; this.setUpActionIconAnimations(); this.setUpSendIconAnimations(); } setUpActionIconAnimations() { this.expandoButtonsOpen = new Value(1); this.targetExpandoButtonsOpen = new Value(1); const prevTargetExpandoButtonsOpen = new Value(1); const expandoButtonClock = new Clock(); const expandoButtonsOpen = block([ cond(neq(this.targetExpandoButtonsOpen, prevTargetExpandoButtonsOpen), [ stopClock(expandoButtonClock), set(prevTargetExpandoButtonsOpen, this.targetExpandoButtonsOpen), ]), cond( neq(this.expandoButtonsOpen, this.targetExpandoButtonsOpen), set( this.expandoButtonsOpen, runTiming( expandoButtonClock, this.expandoButtonsOpen, this.targetExpandoButtonsOpen, true, expandoButtonsAnimationConfig, ), ), ), this.expandoButtonsOpen, ]); this.cameraRollIconStyle = { ...unboundStyles.cameraRollIcon, opacity: expandoButtonsOpen, }; this.cameraIconStyle = { ...unboundStyles.cameraIcon, opacity: expandoButtonsOpen, }; const expandoButtonsWidth = interpolateNode(expandoButtonsOpen, { inputRange: [0, 1], outputRange: [26, 66], }); this.expandoButtonsStyle = { ...unboundStyles.expandoButtons, width: expandoButtonsWidth, }; const expandOpacity = sub(1, expandoButtonsOpen); this.expandIconStyle = { ...unboundStyles.expandIcon, opacity: expandOpacity, }; } setUpSendIconAnimations() { const initialSendButtonContainerOpen = trimMessage(this.props.draft) ? 1 : 0; this.sendButtonContainerOpen = new Value(initialSendButtonContainerOpen); this.targetSendButtonContainerOpen = new Value( initialSendButtonContainerOpen, ); const prevTargetSendButtonContainerOpen = new Value( initialSendButtonContainerOpen, ); const sendButtonClock = new Clock(); const sendButtonContainerOpen = block([ cond( neq( this.targetSendButtonContainerOpen, prevTargetSendButtonContainerOpen, ), [ stopClock(sendButtonClock), set( prevTargetSendButtonContainerOpen, this.targetSendButtonContainerOpen, ), ], ), cond( neq(this.sendButtonContainerOpen, this.targetSendButtonContainerOpen), set( this.sendButtonContainerOpen, runTiming( sendButtonClock, this.sendButtonContainerOpen, this.targetSendButtonContainerOpen, true, sendButtonAnimationConfig, ), ), ), this.sendButtonContainerOpen, ]); const sendButtonContainerWidth = interpolateNode(sendButtonContainerOpen, { inputRange: [0, 1], outputRange: [4, 38], }); this.sendButtonContainerStyle = { width: sendButtonContainerWidth }; } static mediaGalleryOpen(props: Props) { const { keyboardState } = props; return !!(keyboardState && keyboardState.mediaGalleryOpen); } static systemKeyboardShowing(props: Props) { const { keyboardState } = props; return !!(keyboardState && keyboardState.systemKeyboardShowing); } get systemKeyboardShowing() { return ChatInputBar.systemKeyboardShowing(this.props); } immediatelyShowSendButton() { this.sendButtonContainerOpen.setValue(1); this.targetSendButtonContainerOpen.setValue(1); } updateSendButton(currentText: string) { if (this.shouldShowTextInput) { this.targetSendButtonContainerOpen.setValue(currentText === '' ? 0 : 1); } else { this.setUpSendIconAnimations(); } } componentDidMount() { if (this.props.isActive) { this.addEditInputMessageListener(); } if (this.props.navigation) { this.clearBeforeRemoveListener = this.props.navigation.addListener( 'beforeRemove', this.onNavigationBeforeRemove, ); } } componentWillUnmount() { if (this.props.isActive) { this.removeEditInputMessageListener(); } if (this.clearBeforeRemoveListener) { this.clearBeforeRemoveListener(); } } componentDidUpdate(prevProps: Props, prevState: State) { if ( this.state.textEdited && this.state.text && this.props.threadInfo.id !== prevProps.threadInfo.id ) { this.props.dispatch({ type: moveDraftActionType, payload: { oldKey: draftKeyFromThreadID(prevProps.threadInfo.id), newKey: draftKeyFromThreadID(this.props.threadInfo.id), }, }); } else if (!this.state.textEdited && this.props.draft !== prevProps.draft) { this.setState({ text: this.props.draft }); } if (this.props.isActive && !prevProps.isActive) { this.addEditInputMessageListener(); } else if (!this.props.isActive && prevProps.isActive) { this.removeEditInputMessageListener(); } const currentText = trimMessage(this.state.text); const prevText = trimMessage(prevState.text); if ( (currentText === '' && prevText !== '') || (currentText !== '' && prevText === '') ) { this.updateSendButton(currentText); } const systemKeyboardIsShowing = ChatInputBar.systemKeyboardShowing( this.props, ); const systemKeyboardWasShowing = ChatInputBar.systemKeyboardShowing(prevProps); if (systemKeyboardIsShowing && !systemKeyboardWasShowing) { this.hideButtons(); } else if (!systemKeyboardIsShowing && systemKeyboardWasShowing) { this.expandButtons(); } const imageGalleryIsOpen = ChatInputBar.mediaGalleryOpen(this.props); const imageGalleryWasOpen = ChatInputBar.mediaGalleryOpen(prevProps); if (!imageGalleryIsOpen && imageGalleryWasOpen) { this.hideButtons(); } else if (imageGalleryIsOpen && !imageGalleryWasOpen) { this.expandButtons(); this.setIOSKeyboardHeight(); } } addEditInputMessageListener() { invariant( this.props.inputState, 'inputState should be set in addEditInputMessageListener', ); this.props.inputState.addEditInputMessageListener(this.focusAndUpdateText); } removeEditInputMessageListener() { invariant( this.props.inputState, 'inputState should be set in removeEditInputMessageListener', ); this.props.inputState.removeEditInputMessageListener( this.focusAndUpdateText, ); } setIOSKeyboardHeight() { if (Platform.OS !== 'ios') { return; } const { textInput } = this; if (!textInput) { return; } const keyboardHeight = getKeyboardHeight(); if (keyboardHeight === null || keyboardHeight === undefined) { return; } TextInputKeyboardMangerIOS.setKeyboardHeight(textInput, keyboardHeight); } get shouldShowTextInput(): boolean { if (threadHasPermission(this.props.threadInfo, threadPermissions.VOICED)) { return true; } // If the thread is created by somebody else while the viewer is attempting // to create it, the threadInfo might be modified in-place // and won't list the viewer as a member, // which will end up hiding the input. // In this case, we will assume that our creation action // will get translated into a join, and as long // as members are voiced, we can show the input. if (!this.props.threadCreationInProgress) { return false; } return checkIfDefaultMembersAreVoiced(this.props.threadInfo); } render() { const isMember = viewerIsMember(this.props.threadInfo); const canJoin = threadHasPermission( this.props.threadInfo, threadPermissions.JOIN_THREAD, ); let joinButton = null; const threadColor = `#${this.props.threadInfo.color}`; const isEditMode = this.isEditMode(); if (!isMember && canJoin && !this.props.threadCreationInProgress) { let buttonContent; if (this.props.joinThreadLoadingStatus === 'loading') { buttonContent = ( ); } else { const textStyle = colorIsDark(this.props.threadInfo.color) ? this.props.styles.joinButtonTextLight : this.props.styles.joinButtonTextDark; buttonContent = ( Join Chat ); } joinButton = ( ); } const typeaheadRegexMatches = getTypeaheadRegexMatches( this.state.selectionState.text, this.state.selectionState.selection, nativeTypeaheadRegex, ); let typeaheadTooltip = null; if (typeaheadRegexMatches && !isEditMode) { const typeaheadMatchedStrings = { textBeforeAtSymbol: typeaheadRegexMatches[1] ?? '', usernamePrefix: typeaheadRegexMatches[4] ?? '', }; const suggestedUsers = getTypeaheadUserSuggestions( this.props.userSearchIndex, this.props.mentionsCandidates, this.props.viewerID, typeaheadMatchedStrings.usernamePrefix, ); if (suggestedUsers.length > 0) { typeaheadTooltip = ( ); } } let content; const defaultMembersAreVoiced = checkIfDefaultMembersAreVoiced( this.props.threadInfo, ); if (this.shouldShowTextInput) { content = this.renderInput(); } else if ( threadFrozenDueToViewerBlock( this.props.threadInfo, this.props.viewerID, this.props.userInfos, ) && threadActualMembers(this.props.threadInfo.members).length === 2 ) { content = ( - You can't send messages to a user that you've blocked. + You can’t send messages to a user that you’ve blocked. ); } else if (isMember) { content = ( - You don't have permission to send messages. + You don’t have permission to send messages. ); } else if (defaultMembersAreVoiced && canJoin) { content = null; } else { content = ( - You don't have permission to send messages. + You don’t have permission to send messages. ); } const keyboardInputHost = Platform.OS === 'android' ? null : ( ); let editedMessage; if (isEditMode && this.props.editedMessagePreview) { const { message } = this.props.editedMessagePreview; editedMessage = ( Editing message {message.text} ); } return ( {typeaheadTooltip} {joinButton} {editedMessage} {content} {keyboardInputHost} ); } renderInput() { const expandoButton = ( ); const threadColor = `#${this.props.threadInfo.color}`; const expandoButtonsViewStyle = [this.props.styles.innerExpandoButtons]; if (this.isEditMode()) { expandoButtonsViewStyle.push({ display: 'none' }); } return ( {this.state.buttonsExpanded ? expandoButton : null} {this.state.buttonsExpanded ? null : expandoButton} ); } textInputRef = (textInput: ?React.ElementRef) => { this.textInput = textInput; }; clearableTextInputRef = (clearableTextInput: ?ClearableTextInput) => { this.clearableTextInput = clearableTextInput; }; selectableTextInputRef = ( selectableTextInput: ?React.ElementRef, ) => { this.selectableTextInput = selectableTextInput; }; updateText = (text: string) => { this.setState({ text, textEdited: true }); if (this.isEditMode() || this.state.isExitingEditMode) { return; } this.saveDraft(text); }; updateSelectionState: (data: SyncedSelectionData) => void = data => { this.setState({ selectionState: data }); }; saveDraft = _throttle(text => { this.props.dispatch({ type: updateDraftActionType, payload: { key: draftKeyFromThreadID(this.props.threadInfo.id), text, }, }); }, 400); focusAndUpdateTextAndSelection = (text: string, selection: Selection) => { this.selectableTextInput?.prepareForSelectionMutation(text, selection); this.setState({ text, textEdited: true, selectionState: { text, selection }, }); this.saveDraft(text); this.focusAndUpdateButtonsVisibility(); }; focusAndUpdateText = (params: EditInputBarMessageParameters) => { const { message: text, mode } = params; const currentText = this.state.text; if (mode === 'replace') { this.updateText(text); } else if (!currentText.startsWith(text)) { const prependedText = text.concat(currentText); this.updateText(prependedText); } this.focusAndUpdateButtonsVisibility(); }; focusAndUpdateButtonsVisibility = () => { const { textInput } = this; if (!textInput) { return; } this.immediatelyShowSendButton(); this.immediatelyHideButtons(); textInput.focus(); }; onSend = async () => { if (!trimMessage(this.state.text)) { return; } const editedMessage = this.getEditedMessage(); if (editedMessage && editedMessage.id) { this.editMessage(editedMessage.id, this.state.text); return; } this.updateSendButton(''); const { clearableTextInput } = this; invariant( clearableTextInput, 'clearableTextInput should be sent in onSend', ); let text = await clearableTextInput.getValueAndReset(); text = trimMessage(text); if (!text) { return; } const localID = `${localIDPrefix}${this.props.nextLocalID}`; const creatorID = this.props.viewerID; invariant(creatorID, 'should have viewer ID in order to send a message'); invariant( this.props.inputState, 'inputState should be set in ChatInputBar.onSend', ); this.props.inputState.sendTextMessage( { type: messageTypes.TEXT, localID, threadID: this.props.threadInfo.id, text, creatorID, time: Date.now(), }, this.props.threadInfo, this.props.parentThreadInfo, ); }; isEditMode = () => { const editState = this.props.inputState?.editState; const isThisThread = editState?.editedMessage?.threadID === this.props.threadInfo.id; return editState && editState.editedMessage !== null && isThisThread; }; isMessageEdited = () => { let text = this.state.text; text = trimMessage(text); const originalText = this.props.editedMessageInfo?.text; return text !== originalText; }; editMessage = async (messageID: string, text: string) => { if (!this.isMessageEdited()) { this.exitEditMode(); return; } text = trimMessage(text); try { await this.props.editMessage(messageID, text); this.exitEditMode(); } catch (error) { Alert.alert( 'Couldn’t edit the message', 'Please try again later', [{ text: 'OK' }], { cancelable: true, }, ); } }; getEditedMessage = () => { const editState = this.props.inputState?.editState; return editState?.editedMessage; }; onPressExitEditMode = () => { if (!this.isMessageEdited()) { this.exitEditMode(); return; } exitEditAlert(this.exitEditMode); }; scrollToEditedMessage = () => { const editedMessage = this.getEditedMessage(); if (!editedMessage) { return; } const editedMessageKey = messageKey(editedMessage); this.props.inputState?.scrollToMessage(editedMessageKey); }; exitEditMode = () => { this.props.inputState?.setEditedMessage(null, () => { this.updateText(this.props.draft); this.focusAndUpdateButtonsVisibility(); this.updateSendButton(this.props.draft); }); }; onNavigationBeforeRemove = e => { if (!this.isEditMode()) { return; } const { action } = e.data; e.preventDefault(); const saveExit = () => { this.props.inputState?.setEditedMessage(null, () => { this.setState({ isExitingEditMode: true }, () => { if (!this.props.navigation) { return; } this.props.navigation.dispatch(action); }); }); }; if (!this.isMessageEdited()) { saveExit(); return; } exitEditAlert(saveExit); }; onPressJoin = () => { this.props.dispatchActionPromise(joinThreadActionTypes, this.joinAction()); }; async joinAction() { const query = this.props.calendarQuery(); return await this.props.joinThread({ threadID: this.props.threadInfo.id, calendarQuery: { startDate: query.startDate, endDate: query.endDate, filters: [ ...query.filters, { type: 'threads', threadIDs: [this.props.threadInfo.id] }, ], }, }); } expandButtons = () => { if (this.state.buttonsExpanded || this.isEditMode()) { return; } this.targetExpandoButtonsOpen.setValue(1); this.setState({ buttonsExpanded: true }); }; hideButtons() { if ( ChatInputBar.mediaGalleryOpen(this.props) || !this.systemKeyboardShowing || !this.state.buttonsExpanded ) { return; } this.targetExpandoButtonsOpen.setValue(0); this.setState({ buttonsExpanded: false }); } immediatelyHideButtons() { this.expandoButtonsOpen.setValue(0); this.targetExpandoButtonsOpen.setValue(0); this.setState({ buttonsExpanded: false }); } showMediaGallery = () => { const { keyboardState } = this.props; invariant(keyboardState, 'keyboardState should be initialized'); keyboardState.showMediaGallery(this.props.threadInfo); }; dismissKeyboard = () => { const { keyboardState } = this.props; keyboardState && keyboardState.dismissKeyboard(); }; } const unboundStyles = { cameraIcon: { paddingBottom: Platform.OS === 'android' ? 11 : 8, paddingRight: 5, }, cameraRollIcon: { paddingBottom: Platform.OS === 'android' ? 11 : 8, paddingRight: 5, }, container: { backgroundColor: 'listBackground', paddingLeft: Platform.OS === 'android' ? 10 : 5, }, expandButton: { bottom: 0, position: 'absolute', right: 0, }, expandIcon: { paddingBottom: Platform.OS === 'android' ? 13 : 11, paddingRight: 2, }, expandoButtons: { alignSelf: 'flex-end', }, explanation: { color: 'listBackgroundSecondaryLabel', paddingBottom: 4, paddingTop: 1, textAlign: 'center', }, innerExpandoButtons: { alignItems: 'flex-end', alignSelf: 'flex-end', flexDirection: 'row', }, inputContainer: { flexDirection: 'row', }, joinButton: { borderRadius: 8, flex: 1, justifyContent: 'center', marginHorizontal: 12, marginVertical: 3, }, joinButtonContainer: { flexDirection: 'row', height: 48, marginBottom: 8, }, editView: { marginLeft: 20, marginRight: 20, padding: 10, flexDirection: 'row', justifyContent: 'space-between', }, editViewContent: { flex: 1, paddingRight: 6, }, exitEditButton: { marginTop: 6, }, editingLabel: { paddingBottom: 4, }, editingMessagePreview: { color: 'listForegroundLabel', }, joinButtonContent: { flexDirection: 'row', justifyContent: 'center', alignItems: 'center', }, joinButtonTextLight: { color: 'white', fontSize: 20, marginHorizontal: 4, }, joinButtonTextDark: { color: 'black', fontSize: 20, marginHorizontal: 4, }, joinThreadLoadingIndicator: { paddingVertical: 2, }, sendButton: { position: 'absolute', bottom: 4, left: 0, }, sendIcon: { paddingLeft: 9, paddingRight: 8, paddingVertical: 6, }, textInput: { backgroundColor: 'listInputBackground', borderRadius: 12, color: 'listForegroundLabel', fontSize: 16, marginLeft: 4, marginRight: 4, marginTop: 6, marginBottom: 8, maxHeight: 110, paddingHorizontal: 10, paddingVertical: 5, }, }; const joinThreadLoadingStatusSelector = createLoadingStatusSelector( joinThreadActionTypes, ); const createThreadLoadingStatusSelector = createLoadingStatusSelector(newThreadActionTypes); type ConnectedChatInputBarBaseProps = { ...BaseProps, +onInputBarLayout?: (event: LayoutEvent) => mixed, +openCamera: () => mixed, +navigation?: ChatNavigationProp<'MessageList'>, }; function ConnectedChatInputBarBase(props: ConnectedChatInputBarBaseProps) { const navContext = React.useContext(NavContext); const keyboardState = React.useContext(KeyboardContext); const inputState = React.useContext(InputStateContext); const viewerID = useSelector( state => state.currentUserInfo && state.currentUserInfo.id, ); const draft = useSelector( state => state.draftStore.drafts[draftKeyFromThreadID(props.threadInfo.id)] ?? '', ); const joinThreadLoadingStatus = useSelector(joinThreadLoadingStatusSelector); const createThreadLoadingStatus = useSelector( createThreadLoadingStatusSelector, ); const threadCreationInProgress = createThreadLoadingStatus === 'loading'; const calendarQuery = useSelector(state => nonThreadCalendarQuery({ redux: state, navContext, }), ); const nextLocalID = useSelector(state => state.nextLocalID); const userInfos = useSelector(state => state.userStore.userInfos); const styles = useStyles(unboundStyles); const colors = useColors(); const isActive = React.useMemo( () => props.threadInfo.id === activeThreadSelector(navContext), [props.threadInfo.id, navContext], ); const dispatch = useDispatch(); const dispatchActionPromise = useDispatchActionPromise(); const callJoinThread = useServerCall(joinThread); const userSearchIndex = useSelector(userStoreSearchIndex); const { parentThreadID } = props.threadInfo; const parentThreadInfo = useSelector(state => parentThreadID ? threadInfoSelector(state)[parentThreadID] : null, ); const mentionsCandidates = getMentionsCandidates( props.threadInfo, parentThreadInfo, ); const editedMessageInfo = inputState?.editState.editedMessage; const editedMessagePreview = useMessagePreview( editedMessageInfo, props.threadInfo, getDefaultTextMessageRules().simpleMarkdownRules, ); const editMessage = useEditMessage(); return ( ); } type DummyChatInputBarProps = { ...BaseProps, +onHeightMeasured: (height: number) => mixed, }; const noop = () => {}; function DummyChatInputBar(props: DummyChatInputBarProps): React.Node { const { onHeightMeasured, ...restProps } = props; const onInputBarLayout = React.useCallback( (event: LayoutEvent) => { const { height } = event.nativeEvent.layout; onHeightMeasured(height); }, [onHeightMeasured], ); return ( ); } type ChatInputBarProps = { ...BaseProps, +navigation: ChatNavigationProp<'MessageList'>, +route: NavigationRoute<'MessageList'>, }; const ConnectedChatInputBar: React.ComponentType = React.memo(function ConnectedChatInputBar( props: ChatInputBarProps, ) { const { navigation, route, ...restProps } = props; const keyboardState = React.useContext(KeyboardContext); const { threadInfo } = props; const imagePastedCallback = React.useCallback( imagePastedEvent => { if (threadInfo.id !== imagePastedEvent.threadID) { return; } const pastedImage: PhotoPaste = { step: 'photo_paste', dimensions: { height: imagePastedEvent.height, width: imagePastedEvent.width, }, filename: imagePastedEvent.fileName, uri: 'file://' + imagePastedEvent.filePath, selectTime: 0, sendTime: 0, retries: 0, }; navigation.navigate<'ImagePasteModal'>({ name: ImagePasteModalRouteName, params: { imagePasteStagingInfo: pastedImage, thread: threadInfo, }, }); }, [navigation, threadInfo], ); React.useEffect(() => { const imagePasteListener = NativeAppEventEmitter.addListener( 'imagePasted', imagePastedCallback, ); return () => imagePasteListener.remove(); }, [imagePastedCallback]); const chatContext = React.useContext(ChatContext); invariant(chatContext, 'should be set'); const { setChatInputBarHeight, deleteChatInputBarHeight } = chatContext; const onInputBarLayout = React.useCallback( (event: LayoutEvent) => { const { height } = event.nativeEvent.layout; setChatInputBarHeight(threadInfo.id, height); }, [threadInfo.id, setChatInputBarHeight], ); React.useEffect(() => { return () => { deleteChatInputBarHeight(threadInfo.id); }; }, [deleteChatInputBarHeight, threadInfo.id]); const openCamera = React.useCallback(() => { keyboardState?.dismissKeyboard(); navigation.navigate<'ChatCameraModal'>({ name: ChatCameraModalRouteName, params: { presentedFrom: route.key, thread: threadInfo, }, }); }, [keyboardState, navigation, route.key, threadInfo]); return ( ); }); export { ConnectedChatInputBar as ChatInputBar, DummyChatInputBar }; diff --git a/native/crash.react.js b/native/crash.react.js index 15168c9b1..4d5527376 100644 --- a/native/crash.react.js +++ b/native/crash.react.js @@ -1,296 +1,296 @@ // @flow import Icon from '@expo/vector-icons/FontAwesome.js'; import Clipboard from '@react-native-clipboard/clipboard'; import invariant from 'invariant'; import _shuffle from 'lodash/fp/shuffle.js'; import * as React from 'react'; import { View, Text, Platform, StyleSheet, ScrollView, ActivityIndicator, } from 'react-native'; import { sendReportActionTypes, sendReport, } from 'lib/actions/report-actions.js'; import { logOutActionTypes, logOut } from 'lib/actions/user-actions.js'; import { preRequestUserStateSelector } from 'lib/selectors/account-selectors.js'; import type { LogOutResult } from 'lib/types/account-types.js'; import { type ErrorData, type ClientReportCreationRequest, type ReportCreationResponse, reportTypes, } from 'lib/types/report-types.js'; import type { PreRequestUserState } from 'lib/types/session-types.js'; import { actionLogger } from 'lib/utils/action-logger.js'; import { type DispatchActionPromise, useServerCall, useDispatchActionPromise, } from 'lib/utils/action-utils.js'; import { useIsReportEnabled } from 'lib/utils/report-utils.js'; import { sanitizeReduxReport, type ReduxCrashReport, } from 'lib/utils/sanitization.js'; import sleep from 'lib/utils/sleep.js'; import Button from './components/button.react.js'; import ConnectedStatusBar from './connected-status-bar.react.js'; import { commCoreModule } from './native-modules.js'; import { persistConfig, codeVersion } from './redux/persist.js'; import { useSelector } from './redux/redux-utils.js'; import { wipeAndExit } from './utils/crash-utils.js'; const errorTitles = ['Oh no!!', 'Womp womp womp...']; type BaseProps = { +errorData: $ReadOnlyArray, }; type Props = { ...BaseProps, // Redux state +preRequestUserState: PreRequestUserState, // Redux dispatch functions +dispatchActionPromise: DispatchActionPromise, // async functions that hit server APIs +sendReport: ( request: ClientReportCreationRequest, ) => Promise, +logOut: (preRequestUserState: PreRequestUserState) => Promise, +crashReportingEnabled: boolean, }; type State = { +errorReportID: ?string, +doneWaiting: boolean, }; class Crash extends React.PureComponent { errorTitle = _shuffle(errorTitles)[0]; constructor(props) { super(props); this.state = { errorReportID: null, doneWaiting: !props.crashReportingEnabled, }; } componentDidMount() { if (this.state.doneWaiting) { return; } this.props.dispatchActionPromise(sendReportActionTypes, this.sendReport()); this.timeOut(); } async timeOut() { // If it takes more than 10s, give up and let the user exit await sleep(10000); this.setState({ doneWaiting: true }); } render() { const errorText = [...this.props.errorData] .reverse() .map(errorData => errorData.error.message) .join('\n'); let crashID; if (!this.state.doneWaiting) { crashID = ; } else if (this.state.doneWaiting && this.state.errorReportID) { crashID = ( Crash report ID: {this.state.errorReportID} ); } else { crashID = ( Crash reporting can be enabled in the Profile tab. ); } const buttonStyle = { opacity: Number(this.state.doneWaiting) }; return ( {this.errorTitle} - I'm sorry, but the app crashed. + I’m sorry, but the app crashed. {crashID} - Here's some text that's probably not helpful: + Here’s some text that’s probably not helpful: {errorText} ); } async sendReport() { const sanitizedReduxReport: ReduxCrashReport = sanitizeReduxReport({ preloadedState: actionLogger.preloadedState, currentState: actionLogger.currentState, actions: actionLogger.actions, }); const result = await this.props.sendReport({ type: reportTypes.ERROR, platformDetails: { platform: Platform.OS, codeVersion, stateVersion: persistConfig.version, }, errors: this.props.errorData.map(data => ({ errorMessage: data.error.message, stack: data.error.stack, componentStack: data.info && data.info.componentStack, })), ...sanitizedReduxReport, }); this.setState({ errorReportID: result.id, doneWaiting: true, }); } onPressKill = () => { if (!this.state.doneWaiting) { return; } commCoreModule.terminate(); }; onPressWipe = async () => { if (!this.state.doneWaiting) { return; } this.props.dispatchActionPromise(logOutActionTypes, this.logOutAndExit()); }; async logOutAndExit() { try { await this.props.logOut(this.props.preRequestUserState); } catch (e) {} await wipeAndExit(); } onCopyCrashReportID = () => { invariant(this.state.errorReportID, 'should be set'); Clipboard.setString(this.state.errorReportID); }; } const styles = StyleSheet.create({ button: { backgroundColor: '#FF0000', borderRadius: 5, marginHorizontal: 10, paddingHorizontal: 10, paddingVertical: 5, }, buttonText: { color: 'white', fontSize: 16, }, buttons: { flexDirection: 'row', }, container: { alignItems: 'center', backgroundColor: 'white', flex: 1, justifyContent: 'center', }, copyCrashReportIDButtonText: { color: '#036AFF', }, crashID: { flexDirection: 'row', paddingBottom: 12, paddingTop: 2, }, crashIDText: { color: 'black', paddingRight: 8, }, errorReportID: { flexDirection: 'row', height: 20, }, errorReportIDText: { color: 'black', paddingRight: 8, }, errorText: { color: 'black', fontFamily: Platform.select({ ios: 'Menlo', default: 'monospace', }), }, header: { color: 'black', fontSize: 24, paddingBottom: 24, }, scrollView: { flex: 1, marginBottom: 24, marginTop: 12, maxHeight: 200, paddingHorizontal: 50, }, text: { color: 'black', paddingBottom: 12, }, }); const ConnectedCrash: React.ComponentType = React.memo( function ConnectedCrash(props: BaseProps) { const preRequestUserState = useSelector(preRequestUserStateSelector); const dispatchActionPromise = useDispatchActionPromise(); const callSendReport = useServerCall(sendReport); const callLogOut = useServerCall(logOut); const crashReportingEnabled = useIsReportEnabled('crashReports'); return ( ); }, ); export default ConnectedCrash; diff --git a/web/chat/chat-input-bar.react.js b/web/chat/chat-input-bar.react.js index e22d834ce..4108a35fa 100644 --- a/web/chat/chat-input-bar.react.js +++ b/web/chat/chat-input-bar.react.js @@ -1,657 +1,657 @@ // @flow import invariant from 'invariant'; import _difference from 'lodash/fp/difference.js'; import * as React from 'react'; import { joinThreadActionTypes, joinThread, newThreadActionTypes, } from 'lib/actions/thread-actions.js'; import SWMansionIcon from 'lib/components/SWMansionIcon.react.js'; import { createLoadingStatusSelector } from 'lib/selectors/loading-selectors.js'; import { threadInfoSelector } from 'lib/selectors/thread-selectors.js'; import { userStoreSearchIndex } from 'lib/selectors/user-selectors.js'; import { getTypeaheadUserSuggestions, getTypeaheadRegexMatches, getMentionsCandidates, } from 'lib/shared/mention-utils.js'; import type { TypeaheadMatchedStrings } from 'lib/shared/mention-utils.js'; import { localIDPrefix, trimMessage } from 'lib/shared/message-utils.js'; import { threadHasPermission, viewerIsMember, threadFrozenDueToViewerBlock, threadActualMembers, checkIfDefaultMembersAreVoiced, } from 'lib/shared/thread-utils.js'; import type { CalendarQuery } from 'lib/types/entry-types.js'; import type { LoadingStatus } from 'lib/types/loading-types.js'; import { messageTypes } from 'lib/types/message-types-enum.js'; import { threadPermissions } from 'lib/types/thread-permission-types.js'; import { type ThreadInfo, type ClientThreadJoinRequest, type ThreadJoinPayload, type RelativeMemberInfo, } from 'lib/types/thread-types.js'; import { type UserInfos } from 'lib/types/user-types.js'; import { type DispatchActionPromise, useServerCall, useDispatchActionPromise, } from 'lib/utils/action-utils.js'; import css from './chat-input-bar.css'; import TypeaheadTooltip from './typeahead-tooltip.react.js'; import Button from '../components/button.react.js'; import { type InputState, type PendingMultimediaUpload, } from '../input/input-state.js'; import LoadingIndicator from '../loading-indicator.react.js'; import { allowedMimeTypeString } from '../media/file-utils.js'; import Multimedia from '../media/multimedia.react.js'; import { useSelector } from '../redux/redux-utils.js'; import { nonThreadCalendarQuery } from '../selectors/nav-selectors.js'; import { webTypeaheadRegex } from '../utils/typeahead-utils.js'; type BaseProps = { +threadInfo: ThreadInfo, +inputState: InputState, }; type Props = { ...BaseProps, +viewerID: ?string, +joinThreadLoadingStatus: LoadingStatus, +threadCreationInProgress: boolean, +calendarQuery: () => CalendarQuery, +nextLocalID: number, +isThreadActive: boolean, +userInfos: UserInfos, +dispatchActionPromise: DispatchActionPromise, +joinThread: (request: ClientThreadJoinRequest) => Promise, +typeaheadMatchedStrings: ?TypeaheadMatchedStrings, +suggestedUsers: $ReadOnlyArray, +parentThreadInfo: ?ThreadInfo, }; class ChatInputBar extends React.PureComponent { textarea: ?HTMLTextAreaElement; multimediaInput: ?HTMLInputElement; componentDidMount() { this.updateHeight(); if (this.props.isThreadActive) { this.addReplyListener(); } } componentWillUnmount() { if (this.props.isThreadActive) { this.removeReplyListener(); } } componentDidUpdate(prevProps: Props) { if (this.props.isThreadActive && !prevProps.isThreadActive) { this.addReplyListener(); } else if (!this.props.isThreadActive && prevProps.isThreadActive) { this.removeReplyListener(); } const { inputState } = this.props; const prevInputState = prevProps.inputState; if (inputState.draft !== prevInputState.draft) { this.updateHeight(); } if ( inputState.draft !== prevInputState.draft || inputState.textCursorPosition !== prevInputState.textCursorPosition ) { inputState.setTypeaheadState({ canBeVisible: true, }); } const curUploadIDs = ChatInputBar.unassignedUploadIDs( inputState.pendingUploads, ); const prevUploadIDs = ChatInputBar.unassignedUploadIDs( prevInputState.pendingUploads, ); if ( this.multimediaInput && _difference(prevUploadIDs)(curUploadIDs).length > 0 ) { // Whenever a pending upload is removed, we reset the file // HTMLInputElement's value field, so that if the same upload occurs again // the onChange call doesn't get filtered this.multimediaInput.value = ''; } else if ( this.textarea && _difference(curUploadIDs)(prevUploadIDs).length > 0 ) { // Whenever a pending upload is added, we focus the textarea this.textarea.focus(); return; } if ( (this.props.threadInfo.id !== prevProps.threadInfo.id || (inputState.textCursorPosition !== prevInputState.textCursorPosition && this.textarea?.selectionStart === this.textarea?.selectionEnd)) && this.textarea ) { this.textarea.focus(); this.textarea?.setSelectionRange( inputState.textCursorPosition, inputState.textCursorPosition, 'none', ); } } static unassignedUploadIDs( pendingUploads: $ReadOnlyArray, ) { return pendingUploads .filter( (pendingUpload: PendingMultimediaUpload) => !pendingUpload.messageID, ) .map((pendingUpload: PendingMultimediaUpload) => pendingUpload.localID); } updateHeight() { const textarea = this.textarea; if (textarea) { textarea.style.height = 'auto'; const newHeight = Math.min(textarea.scrollHeight, 150); textarea.style.height = `${newHeight}px`; } } addReplyListener() { invariant( this.props.inputState, 'inputState should be set in addReplyListener', ); this.props.inputState.addReplyListener(this.focusAndUpdateText); } removeReplyListener() { invariant( this.props.inputState, 'inputState should be set in removeReplyListener', ); this.props.inputState.removeReplyListener(this.focusAndUpdateText); } render() { const isMember = viewerIsMember(this.props.threadInfo); const canJoin = threadHasPermission( this.props.threadInfo, threadPermissions.JOIN_THREAD, ); let joinButton = null; if (!isMember && canJoin && !this.props.threadCreationInProgress) { let buttonContent; if (this.props.joinThreadLoadingStatus === 'loading') { buttonContent = ( ); } else { buttonContent = ( <>

Join Chat

); } joinButton = (
); } const { pendingUploads, cancelPendingUpload } = this.props.inputState; const multimediaPreviews = pendingUploads.map(pendingUpload => { let mediaSource; if ( pendingUpload.mediaType !== 'encrypted_photo' && pendingUpload.mediaType !== 'encrypted_video' ) { mediaSource = { type: pendingUpload.mediaType, uri: pendingUpload.uri, }; } else { invariant( pendingUpload.encryptionKey, 'encryptionKey should be set for encrypted media', ); mediaSource = { type: pendingUpload.mediaType, holder: pendingUpload.uri, encryptionKey: pendingUpload.encryptionKey, }; } return ( ); }); const previews = multimediaPreviews.length > 0 ? (
{multimediaPreviews}
) : null; let content; // If the thread is created by somebody else while the viewer is attempting // to create it, the threadInfo might be modified in-place and won't // list the viewer as a member, which will end up hiding the input. In // this case, we will assume that our creation action will get translated, // into a join and as long as members are voiced, we can show the input. const defaultMembersAreVoiced = checkIfDefaultMembersAreVoiced( this.props.threadInfo, ); let sendButton; if (this.props.inputState.draft.length) { sendButton = ( ); } if ( threadHasPermission(this.props.threadInfo, threadPermissions.VOICED) || (this.props.threadCreationInProgress && defaultMembersAreVoiced) ) { content = (